diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 4d76b79..0000000 --- a/ui/components/src/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t).getOrElse(t.toString) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t).getOrElse(t.toString), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 4d76b79..0000000 --- a/ui/components/src/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t).getOrElse(t.toString) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t).getOrElse(t.toString), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/model/src/Form.scala b/ui/model/src/Form.scala deleted file mode 100644 index 9ad7f99..0000000 --- a/ui/model/src/Form.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui.model - -import core.* - -case class FormItem[Value]( - id: String, - label: PlainOneLine, - description: Option[PlainMultiLine], - value: Value -) - -case class FormSection( - header: PlainOneLine, - description: Option[PlainMultiLine], - items: List[FormItem[_]] -) - -case class Form(sections: List[FormSection]) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala index 06c10b9..6456863 100644 --- a/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala +++ b/akka-persistence/src/main/scala/fiftyforms/akka/AkkaActorSystem.scala @@ -7,12 +7,14 @@ import akka.cluster.typed.Join case class AkkaActorSystem(system: ActorSystem[?]): - val joinSelf: Task[Unit] = Task.attempt { + val joinSelf: Task[Unit] = ZIO.attempt { val cluster = Cluster(system) cluster.manager ! Join(cluster.selfMember.address) } object AkkaActorSystem: def empty(name: String): TaskLayer[AkkaActorSystem] = - (for system <- Task.attempt(ActorSystem(Behaviors.empty, name)) - yield AkkaActorSystem(system)).toLayer + ZLayer( + for system <- ZIO.attempt(ActorSystem(Behaviors.empty, name)) + yield AkkaActorSystem(system) + ) diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala deleted file mode 100644 index 6a66a32..0000000 --- a/codecs/src/Codecs.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative -package core -package codecs - -import zio.json.* -import zio.prelude.Validation -import works.iterative.tapir.CustomTapir - -private[codecs] case class TextEncoding( - pml: Option[PlainMultiLine], - pon: Option[PlainOneLine], - md: Option[Markdown] -) - -trait Codecs extends JsonCodecs with TapirCodecs - -trait JsonCodecs: - - def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = - v.mapError(_.toString).toEither.left.map(_.mkString(",")) - - private def textCodec[T]( - f: String => Validation[MessageId, T] - ): JsonCodec[T] = - JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) - - given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) - given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) - given JsonCodec[Markdown] = textCodec(Markdown.apply) - -trait TapirCodecs extends CustomTapir: - given Schema[PlainMultiLine] = Schema.string - given Schema[PlainOneLine] = Schema.string - given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala deleted file mode 100644 index 76e812a..0000000 --- a/core/src/MessageCatalogue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative -package core - -// TODO: generic message catalogue -// we need to be able to render HTML messages -// like a list of items for example -trait MessageCatalogue: - def apply(id: MessageId): Option[String] - def apply(msg: UserMessage): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala deleted file mode 100644 index 72c4777..0000000 --- a/core/src/MessageId.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative -package core - -// TODO: use refined's NonEmptyString after macros are ported to Scala 3 -// https://github.com/fthomas/refined/issues/932 -// We could than use the inlined macro to validate non empty strings at runtime - -/* MessageId is an opaque type to mark all keys to translations. - * The intent is to use it during build to generate a list of all keys - * and lately to check if we have all the translations we need - */ -opaque type MessageId = String - -object MessageId: - def apply(id: String): MessageId = id - - inline given Conversion[String, MessageId] with - inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala deleted file mode 100644 index cccaf59..0000000 --- a/core/src/Text.scala +++ /dev/null @@ -1,108 +0,0 @@ -package works.iterative -package core - -import scala.jdk.OptionConverters.given -import zio.prelude.Validation - -/* UserText represents different, more specific variants of String to be able to - * better identify what kind of String are we looking It is meant to be used - * primarily for UI, but sometimes it is useful to store the different text - * representations variantly, eg. it is useful to have the distinction even on - * the level of the data model, not just in the UI - */ -object Text: - - def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) - - def validateNonEmpty[T](text: T)(using - ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" - )(text)(t => ev(t).nonEmpty) - - def firstNewLine(t: String): Int = - val lf = t.indexOf('\n') - def cr = t.indexOf('\r') - if lf != -1 then lf else cr - - def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 - - def firstLineOf(t: String): String = - firstNewLine(t) match - case -1 => t - case i => t.take(i) - -opaque type PlainMultiLine = String - -object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[PlainMultiLine] = - Text.nonEmpty(text) - - given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with - def apply(text: String): Option[PlainMultiLine] = optDirect(text) - - given optString2PlainMultiline - : Conversion[Option[String], Option[PlainMultiLine]] with - def apply(text: Option[String]): Option[PlainMultiLine] = - text.flatMap(optDirect) - - given plainMultiLine2String: Conversion[PlainMultiLine, String] with - def apply(p: PlainMultiLine): String = p.toString - - given optionPlainMultiLine2OptionString - : Conversion[Option[PlainMultiLine], Option[String]] with - def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - - extension (p: PlainMultiLine) def toString: String = p - -opaque type PlainOneLine = String - -object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" - )(text)(!Text.hasNewLine(_)) - - def apply(text: String): Validation[MessageId, PlainOneLine] = - for - _ <- Text.validateNonEmpty(text) - _ <- validateOneLine(text) - yield text - - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = - for _ <- validateOneLine(text) - yield Text.nonEmpty(text) - - def optFirstLine(text: String): Option[PlainOneLine] = - Text.nonEmpty(Text.firstLineOf(text)) - - def firstLine(text: String, default: => String): PlainOneLine = - optFirstLine(text).getOrElse(default) - - def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") - - given string2FirstLineEmpty: Conversion[String, PlainOneLine] = - firstLineEmpty(_) - - extension (p: PlainOneLine) def toString: String = p - -opaque type Markdown = String - -object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = - Text.validateNonEmpty(text) - - def opt(text: String): Validation[Nothing, Option[Markdown]] = - Validation.succeed(optDirect(text)) - - def optDirect(text: String): Option[Markdown] = - Text.nonEmpty(text) - - extension (p: Markdown) def toString: String = p diff --git a/core/src/UserMessage.scala b/core/src/UserMessage.scala deleted file mode 100644 index d2d9343..0000000 --- a/core/src/UserMessage.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative -package core - -// Type-wise naive solution to speicifying user messages. -// A mechanism that will check the message for correct formatting and validate parameters is needed -case class UserMessage(id: MessageId, args: Any*) diff --git a/core/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala new file mode 100644 index 0000000..76e812a --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -0,0 +1,9 @@ +package works.iterative +package core + +// TODO: generic message catalogue +// we need to be able to render HTML messages +// like a list of items for example +trait MessageCatalogue: + def apply(id: MessageId): Option[String] + def apply(msg: UserMessage): Option[String] diff --git a/core/src/main/scala/works/iterative/core/MessageId.scala b/core/src/main/scala/works/iterative/core/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/main/scala/works/iterative/core/Text.scala b/core/src/main/scala/works/iterative/core/Text.scala new file mode 100644 index 0000000..cccaf59 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/Text.scala @@ -0,0 +1,108 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + given string2plainMultiline: Conversion[String, Option[PlainMultiLine]] with + def apply(text: String): Option[PlainMultiLine] = optDirect(text) + + given optString2PlainMultiline + : Conversion[Option[String], Option[PlainMultiLine]] with + def apply(text: Option[String]): Option[PlainMultiLine] = + text.flatMap(optDirect) + + given plainMultiLine2String: Conversion[PlainMultiLine, String] with + def apply(p: PlainMultiLine): String = p.toString + + given optionPlainMultiLine2OptionString + : Conversion[Option[PlainMultiLine], Option[String]] with + def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + given string2FirstLineEmpty: Conversion[String, PlainOneLine] = + firstLineEmpty(_) + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/core/src/main/scala/works/iterative/core/UserMessage.scala b/core/src/main/scala/works/iterative/core/UserMessage.scala new file mode 100644 index 0000000..d2d9343 --- /dev/null +++ b/core/src/main/scala/works/iterative/core/UserMessage.scala @@ -0,0 +1,6 @@ +package works.iterative +package core + +// Type-wise naive solution to speicifying user messages. +// A mechanism that will check the message for correct formatting and validate parameters is needed +case class UserMessage(id: MessageId, args: Any*) diff --git a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala deleted file mode 100644 index 1f97573..0000000 --- a/mongo/it/src/MongoJsonFileRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* -import java.io.File - -object MongoJsonFileRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class ExampleMetadata(osobniCislo: String) - sealed trait ExampleCriteria - case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria - - given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen - - override def spec = suite("Mongo file repository integration spec")( - test("repo can put and read back file")( - for - repo <- ZIO - .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] - fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" - _ <- repo.put( - fname, - "Example content".getBytes(), - ExampleMetadata("10123") - ) - result <- repo.matching(ByOsobniCislo("10123")) - yield assertTrue(result.head.name == fname) - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - import org.mongodb.scala.gridfs.GridFSBucket - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - bucket <- ZIO.attempt( - GridFSBucket(client.getDatabase("test"), "testfiles") - ) - yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( - bucket, { - _ match - case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) - } - )).toLayer diff --git a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala b/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala deleted file mode 100644 index 90a43e3..0000000 --- a/mongo/it/src/MongoJsonRepositoryIntegrationSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.test.* -import zio.json.* -import Assertion.* - -object MongoJsonRepositoryIntegrationSpec extends DefaultRunnableSpec: - case class Example(id: String, value: String) - sealed trait ExampleCriteria - case object All extends ExampleCriteria - case class ById(id: String) extends ExampleCriteria - - given JsonCodec[Example] = DeriveJsonCodec.gen - - override def spec = suite("Mongo repository integration spec")( - test("repo can put and read back")( - for - repo <- ZIO - .service[MongoJsonRepository[Example, String, ExampleCriteria]] - _ <- repo.put(Example("1", "test")) - result <- repo.matching(ById("1")) - yield assertTrue(result.head.value == "test") - ) - ).provideCustomLayer(layer.mapError(TestFailure.fail)) - - val layer = - import org.mongodb.scala.* - import org.mongodb.scala.model.Filters.* - import org.bson.json.JsonObject - import org.mongodb.scala.bson.conversions.Bson - import org.mongodb.scala.bson.Document - MongoConfig.fromEnv >>> MongoClient.layer >>> (for - client <- ZIO.service[MongoClient] - coll <- Task.attempt( - client.getDatabase("test").getCollection[JsonObject]("example") - ) - yield new MongoJsonRepository[Example, String, ExampleCriteria]( - coll, { - _ match - case ById(id) => equal("id", id) - case All => Document() - }, - e => ("id", e.id) - )).toLayer diff --git a/mongo/src/MongoJsonRepository.scala b/mongo/src/MongoJsonRepository.scala deleted file mode 100644 index b40c3e4..0000000 --- a/mongo/src/MongoJsonRepository.scala +++ /dev/null @@ -1,113 +0,0 @@ -package works.iterative.mongo - -import zio.* -import zio.json.* -import zio.config.* -import org.mongodb.scala.* -import org.mongodb.scala.model.Filters.* -import org.bson.json.JsonObject -import org.mongodb.scala.model.ReplaceOptions -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.gridfs.GridFSBucket -import java.io.File -import java.nio.ByteBuffer -import com.mongodb.client.gridfs.model.GridFSUploadOptions -import java.time.Instant -import org.bson.types.ObjectId - -case class MongoConfig(uri: String) - -object MongoConfig: - val configDesc = - import ConfigDescriptor.* - nested("MONGO")(string("URI").default("mongodb://localhost:27017")) - .to[MongoConfig] - val fromEnv = ZConfig.fromSystemEnv( - configDesc, - keyDelimiter = Some('_'), - valueDelimiter = Some(',') - ) - -extension (m: MongoClient.type) - def layer: RLayer[MongoConfig, MongoClient] = - ZIO - .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) - .toLayer - -class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( - collection: MongoCollection[JsonObject], - toFilter: Criteria => Bson, - idFilter: Elem => (String, Key) -): - def matching(criteria: Criteria): Task[List[Elem]] = - val filter = toFilter(criteria) - val query = collection.find(filter) - - for - result <- ZIO.fromFuture(_ => query.toFuture) - decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) - failed = decoded.collect { case (r, Left(msg)) => - s"Unable to decode json : $msg\nJson value:\n$r\n" - } - elems = decoded.collect { case (_, Right(e)) => - e - } - _ <- ZIO - .logWarning( - s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" - ) - .when(failed.nonEmpty) - yield elems.to(List) - - def put(elem: Elem): Task[Unit] = - Task.async(cb => - collection - .replaceOne( - equal.tupled(idFilter(elem)), - JsonObject(elem.toJson), - ReplaceOptions().upsert(true) - ) - .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) - ) - -case class MongoFile( - id: String, - name: String, - created: Instant -) - -class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( - bucket: GridFSBucket, - toFilter: Criteria => Bson -): - - def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = - ZIO - .fromFuture(_ => - bucket - .uploadFromObservable( - name, - Observable(Seq(ByteBuffer.wrap(file))), - GridFSUploadOptions().metadata(Document(metadata.toJson)) - ) - .toFuture - ) - .unit - - def find(id: String): Task[Option[Array[Byte]]] = - ZIO - .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) - .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) - - def matching(criteria: Criteria): Task[List[MongoFile]] = - ZIO - .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) - .map( - _.map(f => - MongoFile( - f.getObjectId.toString, - f.getFilename, - f.getUploadDate.toInstant - ) - ).to(List) - ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..eed0e73 --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonFileRepositoryIntegrationSpec.scala @@ -0,0 +1,51 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* +import java.io.File + +object MongoJsonFileRepositoryIntegrationSpec extends ZIOSpecDefault: + case class ExampleMetadata(osobniCislo: String) + sealed trait ExampleCriteria + case class ByOsobniCislo(osobniCislo: String) extends ExampleCriteria + + given JsonCodec[ExampleMetadata] = DeriveJsonCodec.gen + + override def spec = suite("Mongo file repository integration spec")( + test("repo can put and read back file")( + for + repo <- ZIO + .service[MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]] + fname = "Příliš žluťoučký kůň úpěl ďábelské ódy.txt" + _ <- repo.put( + fname, + "Example content".getBytes(), + ExampleMetadata("10123") + ) + result <- repo.matching(ByOsobniCislo("10123")) + yield assertTrue(result.head.name == fname) + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + import org.mongodb.scala.gridfs.GridFSBucket + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer( + for + client <- ZIO.service[MongoClient] + bucket <- ZIO.attempt( + GridFSBucket(client.getDatabase("test"), "testfiles") + ) + yield new MongoJsonFileRepository[ExampleMetadata, ExampleCriteria]( + bucket, { + _ match + case ByOsobniCislo(osc) => equal("metadata.osobniCislo", osc) + } + ) + ) diff --git a/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala new file mode 100644 index 0000000..0898f7d --- /dev/null +++ b/mongo/src/it/scala/works/iterative/mongo/MongoJsonRepositoryIntegrationSpec.scala @@ -0,0 +1,47 @@ +package works.iterative.mongo + +import zio.* +import zio.test.* +import zio.json.* +import Assertion.* + +object MongoJsonRepositoryIntegrationSpec extends ZIOSpecDefault: + case class Example(id: String, value: String) + sealed trait ExampleCriteria + case object All extends ExampleCriteria + case class ById(id: String) extends ExampleCriteria + + given JsonCodec[Example] = DeriveJsonCodec.gen + + override def spec = suite("Mongo repository integration spec")( + test("repo can put and read back")( + for + repo <- ZIO + .service[MongoJsonRepository[Example, String, ExampleCriteria]] + _ <- repo.put(Example("1", "test")) + result <- repo.matching(ById("1")) + yield assertTrue(result.head.value == "test") + ) + ).provideCustomLayer(layer.mapError(TestFailure.fail)) + + val layer = + import org.mongodb.scala.* + import org.mongodb.scala.model.Filters.* + import org.bson.json.JsonObject + import org.mongodb.scala.bson.conversions.Bson + import org.mongodb.scala.bson.Document + MongoConfig.fromEnv >>> MongoClient.layer >>> ZLayer { + (for + client <- ZIO.service[MongoClient] + coll <- ZIO.attempt( + client.getDatabase("test").getCollection[JsonObject]("example") + ) + yield new MongoJsonRepository[Example, String, ExampleCriteria]( + coll, { + _ match + case ById(id) => equal("id", id) + case All => Document() + }, + e => ("id", e.id) + )) + } diff --git a/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..dddbb71 --- /dev/null +++ b/mongo/src/main/scala/works/iterative/mongo/MongoJsonRepository.scala @@ -0,0 +1,113 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.gridfs.GridFSBucket +import java.io.File +import java.nio.ByteBuffer +import com.mongodb.client.gridfs.model.GridFSUploadOptions +import java.time.Instant +import org.bson.types.ObjectId + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv( + configDesc, + keyDelimiter = Some('_'), + valueDelimiter = Some(',') + ) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZLayer { + ZIO.serviceWithZIO[MongoConfig](c => ZIO.attempt(MongoClient(c.uri))) + } + +class MongoJsonRepository[Elem: JsonCodec, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +): + def matching(criteria: Criteria): Task[List[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + decoded = result.map(r => r.getJson -> r.getJson.fromJson[Elem]) + failed = decoded.collect { case (r, Left(msg)) => + s"Unable to decode json : $msg\nJson value:\n$r\n" + } + elems = decoded.collect { case (_, Right(e)) => + e + } + _ <- ZIO + .logWarning( + s"Errors while reading json entries from MongoDB:\n${failed.mkString("\n")}" + ) + .when(failed.nonEmpty) + yield elems.to(List) + + def put(elem: Elem): Task[Unit] = + ZIO.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(ZIO.unit), t => cb(ZIO.fail(t))) + ) + +case class MongoFile( + id: String, + name: String, + created: Instant +) + +class MongoJsonFileRepository[Metadata: JsonCodec, Criteria]( + bucket: GridFSBucket, + toFilter: Criteria => Bson +): + + def put(name: String, file: Array[Byte], metadata: Metadata): Task[Unit] = + ZIO + .fromFuture(_ => + bucket + .uploadFromObservable( + name, + Observable(Seq(ByteBuffer.wrap(file))), + GridFSUploadOptions().metadata(Document(metadata.toJson)) + ) + .toFuture + ) + .unit + + def find(id: String): Task[Option[Array[Byte]]] = + ZIO + .fromFuture(_ => bucket.downloadToObservable(ObjectId(id)).toFuture) + .map(r => if r.isEmpty then None else Some(r.map(_.array).reduce(_ ++ _))) + + def matching(criteria: Criteria): Task[List[MongoFile]] = + ZIO + .fromFuture(_ => bucket.find(toFilter(criteria)).toFuture) + .map( + _.map(f => + MongoFile( + f.getObjectId.toString, + f.getFilename, + f.getUploadDate.toInstant + ) + ).to(List) + ) diff --git a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala index a1dc90d..680a414 100644 --- a/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala +++ b/tapir/jvm/src/main/scala/works/iterative/tapir/CustomTapirPlatformSpecific.scala @@ -49,7 +49,7 @@ case None => identity } - val clientLayer: RLayer[zio.System, Backend] = + val clientLayer: TaskLayer[Backend] = ZLayer { for sessionId <- zio.System.env("SESSION") diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index 68d7196..b9b3689 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -2,11 +2,11 @@ import sttp.model.Uri -opaque type BaseUri = Option[Uri] +case class BaseUri(value: Option[Uri]) object BaseUri: - def apply(optU: Option[Uri]): BaseUri = optU - def apply(u: Uri): BaseUri = Some(u) + def apply(optU: Option[Uri]): BaseUri = BaseUri(optU) + def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v + extension (v: BaseUri) def toUri: Option[Uri] = v.value diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..62ff5db --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..aea3c6f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,17 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) + + override def apply(msg: UserMessage): Option[String] = + apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..e7904f3 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,49 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..f23f0e8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..3412f3f --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..625da88 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Modifier[HtmlElement] = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..c63a79e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,79 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..cb405e9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..887abe9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,58 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx + .messages(s"action.$name.title") + .getOrElse(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..405d371 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,45 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..73ed43b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..33c7576 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,13 @@ +package works.iterative +package ui.components.tailwind.form + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..f742d3a --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + ctx.messages(property.name) + ) + ) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..4d76b79 --- /dev/null +++ b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t).getOrElse(t.toString) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t).getOrElse(t.toString), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index 62ff5db..0000000 --- a/ui/components/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala deleted file mode 100644 index aea3c6f..0000000 --- a/ui/components/src/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def apply(id: MessageId): Option[String] = - messages.get(id.toString) - - override def apply(msg: UserMessage): Option[String] = - apply(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/components/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Alert.scala b/ui/components/src/ui/components/tailwind/Alert.scala deleted file mode 100644 index e7904f3..0000000 --- a/ui/components/src/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,49 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 3412f3f..0000000 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/ui/components/tailwind/Layout.scala b/ui/components/src/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 625da88..0000000 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Modifier[HtmlElement] - extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Modifier[HtmlElement] = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Modifier[HtmlElement] = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Modifier[HtmlElement] = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/ui/components/tailwind/StyleGuide.scala b/ui/components/src/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index c63a79e..0000000 --- a/ui/components/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,79 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index cb405e9..0000000 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - def toLabeled(n: UIString, v: V): OptionalLabeledValue - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - def toLabeled(n: UIString, v: V): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] = - (d: LeftAlignedInCard[A]) => - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 887abe9..0000000 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,58 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx - .messages(s"action.$name.title") - .getOrElse(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 405d371..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 73ed43b..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index 33c7576..0000000 --- a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/Switch.scala b/ui/components/src/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index f742d3a..0000000 --- a/ui/components/src/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - ctx.messages(property.name) - ) - ) - ) diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/components/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 4d76b79..0000000 --- a/ui/components/src/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t).getOrElse(t.toString) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t).getOrElse(t.toString), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/model/src/Form.scala b/ui/model/src/Form.scala deleted file mode 100644 index 9ad7f99..0000000 --- a/ui/model/src/Form.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui.model - -import core.* - -case class FormItem[Value]( - id: String, - label: PlainOneLine, - description: Option[PlainMultiLine], - value: Value -) - -case class FormSection( - header: PlainOneLine, - description: Option[PlainMultiLine], - items: List[FormItem[_]] -) - -case class Form(sections: List[FormSection]) diff --git a/ui/model/src/main/scala/works/iterative/ui/model/Form.scala b/ui/model/src/main/scala/works/iterative/ui/model/Form.scala new file mode 100644 index 0000000..9ad7f99 --- /dev/null +++ b/ui/model/src/main/scala/works/iterative/ui/model/Form.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui.model + +import core.* + +case class FormItem[Value]( + id: String, + label: PlainOneLine, + description: Option[PlainMultiLine], + value: Value +) + +case class FormSection( + header: PlainOneLine, + description: Option[PlainMultiLine], + items: List[FormItem[_]] +) + +case class Form(sections: List[FormSection])